FeedReplyComposer.tsx 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. 'use client';
  2. import { useEffect, useRef, useState } from 'react';
  3. import { X } from 'lucide-react';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import useAuth from '@/hooks/useAuth';
  6. import EmojiPicker from '@/app/component/EmojiPicker';
  7. import { FeedReply } from '@/types/feed/post';
  8. const MAX_LENGTH = 300;
  9. type Props = {
  10. postID: number;
  11. replyTarget: FeedReply|null;
  12. onClearReplyTarget: () => void;
  13. onSubmitted: () => void;
  14. };
  15. export default function FeedReplyComposer({ postID, replyTarget, onClearReplyTarget, onSubmitted }: Props) {
  16. const { member, loginCheck } = useAuth();
  17. const [content, setContent] = useState('');
  18. const [submitting, setSubmitting] = useState(false);
  19. const [error, setError] = useState<string|null>(null);
  20. const textareaRef = useRef<HTMLTextAreaElement|null>(null);
  21. const isOver = content.length > MAX_LENGTH;
  22. useEffect(() => {
  23. if (replyTarget) {
  24. textareaRef.current?.focus();
  25. }
  26. }, [replyTarget]);
  27. const handleEmojiSelect = (emoji: string) => {
  28. const textarea = textareaRef.current;
  29. if (!textarea) {
  30. setContent((prev) => prev + emoji);
  31. return;
  32. }
  33. const start = textarea.selectionStart ?? content.length;
  34. const end = textarea.selectionEnd ?? content.length;
  35. const next = content.slice(0, start) + emoji + content.slice(end);
  36. setContent(next);
  37. requestAnimationFrame(() => {
  38. textarea.focus();
  39. const caret = start + emoji.length;
  40. textarea.setSelectionRange(caret, caret);
  41. });
  42. };
  43. const handleSubmit = async () => {
  44. if (!loginCheck() || submitting) {
  45. return;
  46. }
  47. const trimmed = content.trim();
  48. if (trimmed.length === 0) {
  49. setError('답글 내용을 입력해주세요.');
  50. return;
  51. }
  52. if (isOver) {
  53. setError(`답글은 최대 ${MAX_LENGTH}자까지 작성할 수 있습니다.`);
  54. return;
  55. }
  56. setSubmitting(true);
  57. setError(null);
  58. try {
  59. const body: { content: string; parentCommentID?: number } = {
  60. content: trimmed
  61. };
  62. if (replyTarget) {
  63. body.parentCommentID = replyTarget.id;
  64. }
  65. const res = await fetchApi(`/api/feed/post/${postID}/comment`, {
  66. method: 'POST',
  67. body,
  68. silent: true
  69. });
  70. if (!res.success) {
  71. setError(res.message ?? '댓글 등록 실패');
  72. return;
  73. }
  74. setContent('');
  75. onClearReplyTarget();
  76. onSubmitted();
  77. } catch (err) {
  78. console.error(err);
  79. setError(err instanceof Error ? err.message : '답글 등록 실패');
  80. } finally {
  81. setSubmitting(false);
  82. }
  83. };
  84. if (!member) {
  85. return (
  86. <div className="feed__reply-composer feed__reply-composer--login">
  87. <p>답글을 남기려면 로그인이 필요합니다.</p>
  88. </div>
  89. );
  90. }
  91. return (
  92. <div className="feed__reply-composer">
  93. <div className="feed__reply-composer-avatar">
  94. {member.thumb ? (
  95. <img src={member.thumb} alt={member.name ?? member.sid} />
  96. ) : (
  97. <span className="feed__reply-composer-avatar-fallback">{(member.name ?? member.sid).charAt(0).toUpperCase()}</span>
  98. )}
  99. </div>
  100. <div className="feed__reply-composer-body">
  101. {replyTarget && (
  102. <div className="feed__reply-composer-target">
  103. <span>{(replyTarget.authorName || replyTarget.authorSID) ?? '알 수 없음'}님에게 답글</span>
  104. <button type="button" onClick={onClearReplyTarget} aria-label="답글 대상 취소">
  105. <X size={14} />
  106. </button>
  107. </div>
  108. )}
  109. <textarea
  110. ref={textareaRef}
  111. id="feed-reply-composer-content"
  112. name="content"
  113. className="feed__reply-composer-input"
  114. placeholder={replyTarget ? '답글 쓰기...' : '생각을 나눠보세요'}
  115. value={content}
  116. onChange={(e) => setContent(e.target.value)}
  117. rows={2}
  118. maxLength={MAX_LENGTH + 50}
  119. disabled={submitting}
  120. />
  121. {error && <p className="feed__reply-composer-error">{error}</p>}
  122. <div className="feed__reply-composer-footer">
  123. <div className="feed__reply-composer-tools">
  124. <EmojiPicker onEmojiSelect={handleEmojiSelect} />
  125. </div>
  126. <div className="feed__reply-composer-submit">
  127. <span className={`feed__reply-composer-count${isOver ? ' feed__reply-composer-count--over' : ''}`}>
  128. {content.length} / {MAX_LENGTH}
  129. </span>
  130. <button
  131. type="button"
  132. onClick={handleSubmit}
  133. disabled={submitting || content.trim().length === 0 || isOver}
  134. className="feed__reply-composer-btn"
  135. >
  136. {submitting ? '등록 중...' : '답글'}
  137. </button>
  138. </div>
  139. </div>
  140. </div>
  141. </div>
  142. );
  143. }